Moonglade

Sigi framework introduction

February 15, 2020


这篇文章只会介绍 Sigi framework 的设计理念以及试图解决哪些问题,不会对各个 API 有详细的描述,如果你想开始学习并使用 Sigi framework 请到 https://sigi.how/zh/basic

从 Redux 而来

在 Redux 时代,有无数人努力着让业务中的样板代码(boilerplate code) 稍微少一点。最早的时候,我们通过 redux-actions redux-toolkit 等工具库减少样板代码,在不考虑 TypeScript 的情况下这些工具有非常好的抽象效果,在这两个库的文档中可以看到在 JavaScript 项目中使用它们之后带来的显著效果。但随着 TypeScript 的到来,有很多种方式的努力都付诸东流,因为大家发现除了与 Redux 相关的 Action/Reducer/Middleware 三件套的样板代码需要去除,连接这三个部分的类型代码也同样多如牛毛。

业务逻辑割裂

业务逻辑割裂分为两个方面,一个是 code path 断裂,一个是 类型推导割裂

Redux 分离 Action, ReducerSide effect 的设计能让我们在写业务的时候更容易写出干净无副作用的组件,并且能让我们更好分离各部分业务的职责。而这种设计如果不加以封装则会让代码的 Code path 过于冗长,不利于连贯的进行代码阅读与业务逻辑理解,提高代码的维护成本。而早期社区推崇的Rails 风格的抽象方式(将 action/reducer/side effect 的代码分文件夹放在一起) 更是极大的放大了这一问题。

随着社区实践的完善,大家发现遵循 Domain style/Ducts 来组织业务逻辑相对于 Rails 风格 更适合大型 Redux应用,但它还是没有彻底解决业务逻辑 Code path 过长、逻辑割裂的问题。我们以一个典型的基于 redux-actionsDucts 风格组织的 Redux 应用为例:

// count.module.ts

const ADD_COUNT = createAction<number>('ADD_COUNT')
export interface CountDispatchProps {
  addOne: typeof ADD_COUNT
}
export interface CountStateProps {
  count: number
}

// reducer
export const reducer = handleActions(
  {
    [`${ADD_COUNT}`]: (state: CountStateProps, { payload }: Action<number>) => {
      return { ...state, count: state.count + payload }
    },
  },
  { count: 0 },
)
// own props which passed by parent components
interface ÇountOwnProps {
  countToAdd: number
}

type CountProps = CountStateProps & CountDispatchProps & CountOwnProps

class CountComponent extends React.PureComponent<CountProps> {
  private onClickAddCount = () => {
    this.props.addCount(this.props.countToAdd)
  }

  render() {
    return (
      <div>
        <button onClick={this.onClickAddCount}>add count</button>
        {this.props.count}
      </div>
    )
  }
}

// react actions dispatcher
export const Count = connect(mapStateToProps, (dispatch) =>
  bindActionCreators(
    {
      addCount: ADD_COUNT,
    } as CountDispatchProps,
    dispatch,
  ),
)(CountComponent)

我们在阅读这个简单的组件的业务逻辑的时候,如果想看 this.props.addCount背后到底是什么样的业务逻辑,需要先找到 connect 中,这个 props 是如何被传入组件的,然后找到这个 dispatch props 对应的 Aciton 是什么,然后跳转到 count.module.ts 文件中,找到 Aciton 的定义,再利用文件内搜索功能,找到哪里的 Reducer/Side effect 处理了这个 Action。归纳下来:

  • 找到 mapDispatchToProps 中对应的 Action
  • 找到 module 文件中对应的 Action
  • 搜索 Action 对应的 Reducer/Side effect

并且随之而来的是,在 TypeScript 的项目中,Action 处定义的类型并不能自动传递给调用这个 Action 的地方。比如在上面的例子中,ADD_COUNT 定义的类型 payloadnumber,而在消费这个 Actionreducer 中,payload 类型必须重新指定一次,而且即使不一致也不会被 TypeScript 捕获到。

分形

Redux 的中心是一个单例的 Store 对象,任何基于 Redux 的组件都必须关联到这个 Store 上才能正常使用。这意味着在编写一个带业务逻辑的组件时,如果我们想要使用 Redux 抽象一些复杂的逻辑,或者复用已有的一些基于Redux 的通用代码时,不得不考虑暴露的 API 的易用性。这些情况下简单的暴露组件是不够的,还必须让使用方把自己的 reducer/side effect 等逻辑接入到 Store 中,并且还要考虑命名冲突等问题。

也就是说基于 Redux 很难做出分形的组件

Sigi 的设计

逻辑的连贯

Sigi 的核心借鉴了 Redux 的设计,所有的高层次的概念都是基于 Action/Reducer/Side effect 封装而成。在业务代码中我们的 API 设计理念跟 Redux 也比较类似,强制让业务的 Dispatcher/Reducer/Side effect 的代码分开编写,保持逻辑的干净。而在彻底的分离背后,我们也保持了逻辑的连贯。与大多数 Redux 封装不一样的是,Sigidispatch props 可以通过 TypeScript 提供的的 jump to definition 功能直接跳转到 dispatcher 对应的逻辑:

Try it!

// index.tsx
import 'reflect-metadata'
import React from 'react'
import { render } from 'react-dom'
import { useEffectModule } from '@sigi/react'
import { initDevtool } from '@sigi/devtool'

import { AppModule } from './app.module'

function App() {
  const [state, dispatcher] = useEffectModule(AppModule)

  const loading = state.loading ? <div>loading</div> : null

  const list = (state.list || []).map((value) => <li key={value}>{value}</li>)
  return (
    <div>
      <h1>Hello CodeSandbox</h1>
      <button onClick={dispatcher.fetchList}>fetchList</button>
      <button onClick={dispatcher.cancel}>cancel</button>
      {loading}
      <ul>{list}</ul>
    </div>
  )
}

const rootElement = document.getElementById('app')
render(<App />, rootElement)

initDevtool()
import { Module, EffectModule, Reducer, Effect, Action } from '@sigi/core'
import { Observable } from 'rxjs'
import {
  exhaustMap,
  takeUntil,
  map,
  tap,
  startWith,
  endWith,
} from 'rxjs/operators'

import { HttpClient } from './http.service'

interface AppState {
  loading: boolean
  list: string[] | null
}

@Module('App')
export class AppModule extends EffectModule<AppState> {
  defaultState: AppState = {
    list: null,
    loading: false,
  }

  constructor(private readonly httpClient: HttpClient) {
    super()
  }

  @Reducer()
  cancel(state: AppState) {
    return { ...state, ...this.defaultState }
  }

  @Reducer()
  setLoading(state: AppState, loading: boolean) {
    return { ...state, loading }
  }

  @Reducer()
  setList(state: AppState, list: string[]) {
    return { ...state, list }
  }

  @Effect()
  fetchList(payload$: Observable<void>): Observable<Action> {
    return payload$.pipe(
      exhaustMap(() => {
        return this.httpClient.get(`/resources`).pipe(
          tap(() => {
            console.info('Got response')
          }),
          map((response) => this.getActions().setList(response)),
          startWith(this.getActions().setLoading(true)),
          endWith(this.getActions().setLoading(false)),
          takeUntil(this.getAction$().cancel),
        )
      }),
    )
  }
}

在这个代码示例中,组件中的 diaptcher.fetchList 可以直接跳转到 EffectModulefetchList 实现,并且类型签名是自动互相匹配的。比如声明这样一个 Reducer:

@Reducer()
addCount(state: State, payload: number) {
  return { ...state, count: state.count + payload }
}

它对应的 dispatcher.addCount 签名就是 (payload: number) => void,在你不小心传入错误类型的 payload 之后,TypeScript 会直接告诉你错误的原因。在 SigiEffectModule 中,EffectImmerReducer 也有同样的效果。

分形

Sigi 没有全局 Store 的概念,它在全局唯一的限制是每一个 EffectModule 的名字必须不一样,这样做是为了更方便的在 devtool 中追踪异步事件的流程,以及方便 SSR 场景下将数据从 Node 透传到前端。

所以在实践中,你可以大量依赖 Sigi 去抽象带复杂业务逻辑的业务组件,将各种复杂的状态封装到局部。而对外暴露的 API 就仅仅是一个普通的 React 组件。

测试

Sigi 底层有一个小巧的 Denpendencies injection 实现,所以使用 Sigi 的时候推荐将大部分复杂的业务通过 Class 组织起来,然后通过 DI 组合它们。这样做有几个好处,其中最重要的部分就体现在测试的便捷性上。

下面两个代码片段展示了有 DI 和没有 DI 的时候在编写测试上的区别:

import { stub, useFakeTimers, SinonFakeTimers, SinonStub } from 'sinon'
import { Store } from 'redux'
import { noop } from 'lodash'
const fakeAjax = {
  getJSON: noop,
}

jest.mock('rxjs/ajax', () => ({ ajax: fakeAjax }))
import { configureStore } from '@demo/app/redux/store'
import { GlobalState } from '@demo/app/redux'
import { REQUESTED_USER_REPOS } from './index'
import { of, timer, throwError } from 'rxjs'
import { mapTo } from 'rxjs/operators'

describe('raw redux-observable specs', () => {
  let store: Store<GlobalState>
  let dispose: () => void
  let fakeTimer: SinonFakeTimers
  let ajaxStub: SinonStub
  const debounce = 300 // debounce in epic

  beforeEach(() => {
    store = configureStore().store
    dispose = store.subscribe(noop)
    fakeTimer = useFakeTimers()
    ajaxStub = stub(fakeAjax, 'getJSON')
  })

  afterEach(() => {
    ajaxStub.restore()
    fakeTimer.restore()
    dispose()
  })

  it('should get empty repos by name', () => {
    const username = 'fake user name'
    ajaxStub.returns(of([]))
    store.dispatch(REQUESTED_USER_REPOS(username))
    fakeTimer.tick(debounce)
    expect(store.getState().raw.repos).toHaveLength(0)
  })

  it('should get repos by name', () => {
    const username = 'fake user name'
    const repos = [{ name: 1 }, { name: 2 }]
    ajaxStub.returns(of(repos))
    store.dispatch(REQUESTED_USER_REPOS(username))
    fakeTimer.tick(debounce)
    expect(store.getState().raw.repos).toEqual(repos)
  })

  it('should set loading and finish loading', () => {
    const username = 'fake user name'
    const delay = 300
    ajaxStub.returns(timer(delay).pipe(mapTo([])))
    store.dispatch(REQUESTED_USER_REPOS(username))
    expect(store.getState().raw.loading).toBe(false)
    fakeTimer.tick(debounce)
    expect(store.getState().raw.loading).toBe(true)
    fakeTimer.tick(delay)
    expect(store.getState().raw.loading).toBe(false)
  })

  it('should catch error', () => {
    const username = 'fake user name'
    const debounce = 300 // debounce in epic
    ajaxStub.returns(throwError(new TypeError('whatever')))
    store.dispatch(REQUESTED_USER_REPOS(username))
    fakeTimer.tick(debounce)
    expect(store.getState().raw.error).toBe(true)
  })
})
import { Test, SigiTestModule, SigiTestStub } from '@sigi/testing'
import { SinonFakeTimers, SinonStub, useFakeTimers, stub } from 'sinon'
import { of, timer, throwError } from 'rxjs'
import { mapTo } from 'rxjs/operators'

import { RepoService } from './service'
import { HooksModule, StateProps } from './index'

class FakeRepoService {
  getRepoByUsers = stub()
}

describe('ayanami specs', () => {
  let fakeTimer: SinonFakeTimers
  let ajaxStream$:
  let moduleStub: SigiTestStub<AppModule, AppState>
  const debounce = 300 // debounce in epic

  beforeEach(() => {
    fakeTimer = useFakeTimers()
    const testModule = Test.createTestingModule({
      TestModule: SigiTestModule,
    })
      .overrideProvider(RepoService)
      .useClass(FakeRepoService)
      .compile()
    moduleStub = testModule.getTestingStub(HooksModule)
    const ajaxStub = testModule.getInstance(RepoService).getRepoByUsers as SinonStub

  })

  afterEach(() => {
    ajaxStub.reset()
    fakeTimer.restore()
  })

  it('should get empty repos by name', () => {
    const username = 'fake user name'
		ajaxStub.returns(of([]))
    moduleStub.dispatcher.fetchRepoByUser(username)
    fakeTimer.tick(debounce)
    expect(moduleStub.getState().repos).toHaveLength(0)
  })

  it('should get repos by name', () => {
    const username = 'fake user name'
    const repos = [{ name: 1 }, { name: 2 }]
    ajaxStub.returns(of(repos))
    moduleStub.dispatcher.fetchRepoByUser(username)
    fakeTimer.tick(debounce)
    expect(moduleStub.getState().repos).toEqual(repos)
  })

  it('should set loading and finish loading', () => {
    const username = 'fake user name'
    const delay = 300
    ajaxStub.returns(timer(delay).pipe(mapTo([])))
    moduleStub.dispatcher.fetchRepoByUser(username)
    expect(moduleStub.getState().loading).toBe(false)
    fakeTimer.tick(debounce)
    expect(moduleStub.getState().loading).toBe(true)
    fakeTimer.tick(delay)
    expect(moduleStub.getState().loading).toBe(false)
  })

  it('should catch error', () => {
    const username = 'fake user name'
    const debounce = 300 // debounce in epic
    ajaxStub.returns(throwError(new TypeError('whatever')))
    moduleStub.dispatcher.fetchRepoByUser(username)
    fakeTimer.tick(debounce)
    expect(moduleStub.getState().error).toBe(true)
  })
})

从示例可以看出,编写Sigi 的测试在 Mock/Stub/Spy 上有非常大的优势,并且在测试中的代码与业务代码在逻辑与类型上也是连贯的,更利于维护。在实践中,我们推荐对 SigiEffectModule 进行全面的单元测试,而 组件 的逻辑尽量保持简单干净,这样可以大大降低测试的维护与运行成本(Mock 掉外部依赖的纯 EffectModule 测试代码运行起来非常快!)。

你也可以在 Sigi 文档 · 编写测试 中实际运行感受一下 Sigi 编写测试的便捷性。

SSR

对于需要 SEO 或者需要提升用户首屏体验的项目来说,SSR 是不得不考虑的因素。Sigi 设计了一套强大且易用的 SSR API。

Server 端运行副作用

@sigi/ssr 模块中提供了一个 emitSSREffects 的函数,它的签名如下:

function emitSSREffects<Context>(ctx: Context, modules: Constructor<EffectModule<unkown>>[]) => Promise<StateToPersist>

SigiEffect 在 SSR 模式下只需要将对应的 Decorator 换成 SSREffect 就可以复用了。在 Server 端与在 Client 端不一样的是,Effect 对应的 Payload 的获取上下文是组件,也就是组件作用域内的 Props/State/Router 等一系列客户端特有的状态。而在 Server 端,SSREffect 提供了 payloadGetter option 来在 Server 端获取 payload。它的签名如下:

payloadGetter: (ctx: Context, skip: () => typeof SKIP_SYMBOL) => Payload | Promise<Payload> | typeof SKIP_SYMBOL

其中第一个 ctx 就是 emitSSREffects 中的第一个参数,通常在 Express 下你可以传入 Reqest 对象,在 Koa 下你可以传入 Context 对象。

第二个参数 skip 是一个函数,如果在某种业务条件下,比如权限错误直接 return skip()Sigi 就会跳过这个 Effect,不再等待它的值。

因为 Sigi 的设计是基于 RxJS 的,在一个应用的生命周期内,每个 Effect 都 可能会有多个值emit。所以在需要 SSR 的Effect 的逻辑中,我们还要保证获取到 SSR 需要的数据后,emit 一个 TERMINATE_ACTION 来告诉 Sigi 这个 Effect 已经运行完成了。

emitSSREffects 函数会等待所有传入的 EffectModuleSSREffectemit 了一个 TERMINATE_ACTION 之后,将它们的 state 返回出来。

这个时候,再 render 包含 Sigi EffectModule 的组件,它们将直接使用 emitSSREffects 之后 Module 中的组件状态,从而渲染出对应的 HTML。而 emitSSREffects 返回的 StateToPersist 对象,你可以调用上面的 renderToJSX 方法将它放到渲染出来的 HTML 中。这样做之后在服务端获取过的数据将通过 HTML 透传到客户端,从而在客户端第一次触发同样的的 Effect 的时候直接忽略掉,节省请求和计算。当然这个行为也可以通过 SSREffect 的 option 中 skipFirstClientDispatch 选项关闭。

SSR example 中,有一个简单的 EffectModule 模块能比较好的示意这个过程:

import {
  Module,
  EffectModule,
  ImmerReducer,
  TERMINATE_ACTION,
} from '@sigi/core'
import { SSREffect } from '@sigi/ssr'
import { Observable, of } from 'rxjs'
import {
  exhaustMap,
  map,
  startWith,
  delay,
  endWith,
  mergeMap,
} from 'rxjs/operators'
import { Draft } from 'immer'
import md5 from 'md5'

interface State {
  count: number
  sigiMd5: string | null
}

@Module('demoModule')
export class DemoModule extends EffectModule<State> {
  defaultState = {
    count: 0,
    sigiMd5: null,
  }

  @ImmerReducer()
  setCount(state: Draft<State>, count: number) {
    state.count = count
  }

  @ImmerReducer()
  addOne(state: Draft<State>) {
    state.count++
  }

  @ImmerReducer()
  setSigiMd5(state: Draft<State>, hashed: string) {
    state.sigiMd5 = hashed
  }

  @SSREffect({
    payloadGetter: () => {
      return md5('sigi')
    },
  })
  getSigiMd5(payload$: Observable<string>) {
    return payload$.pipe(
      delay(100), // mock async
      mergeMap((hashed) =>
        of(this.getActions().setSigiMd5(hashed), TERMINATE_ACTION),
      ),
    )
  }

  @SSREffect()
  asyncEffect(payload$: Observable<void>) {
    return payload$.pipe(
      exhaustMap(() =>
        of({ count: 10 }).pipe(
          delay(1000),
          map(({ count }) => this.getActions().setCount(count)),
          startWith(this.getActions().setCount(0)),
          endWith(TERMINATE_ACTION),
        ),
      ),
    )
  }
}
// renderer.tsx
import 'reflect-metadata'

import { resolve } from 'path'
import fs from 'fs'
import React from 'react'
import { renderToNodeStream } from 'react-dom/server'
import webpack from 'webpack'
import { Request, Response } from 'express'
import { emitSSREffects } from '@sigi/ssr'
import { SSRContext } from '@sigi/react'

import { Home } from '@c/home'
import { DemoModule } from '@c/module'

export async function renderer(req: Request, res: Response) {
  const state = await emitSSREffects(req, [DemoModule])

  const stats: webpack.Stats.ToJsonOutput = JSON.parse(
    fs.readFileSync(resolve(__dirname, '../client/output-stats.json'), {
      encoding: 'utf8',
    }),
  )
  const scripts = (stats.assets || []).map((asset) => (
    <script key={asset.name} src={`/${asset.name}`} />
  ))

  const html = renderToNodeStream(
    <html>
      <head>
        <meta charSet="UTF-8" />
        <meta lang="zh-cms-hans" />
        <title>Sigi ssr example</title>
      </head>
      <body>
        <div id="app">
          <SSRContext.Provider value={req}>
            <Home />
          </SSRContext.Provider>
        </div>
        {state.renderToJSX()}
        {scripts}
      </body>
    </html>,
  )

  res.status(200)
  html.pipe(res)
}

建议将 SSR example 项目下载并运行,深入感受一下 SigiSSR 场景下的设计。

Tree shaking

在使用同构(Isomorphic) SSR 框架时,我们有时候会出现这样的尴尬场景: 我们编写的包含大量 Server 端业务逻辑 的代码被打包工具打包到了 Client 端产物中。这些逻辑里通常包含了很多 请求/缓存逻辑,有时候甚至会 require 一些只适合在 Node 下使用的体积巨大的第三方库,我们通常需要很复杂的工程化手段消除这些逻辑带来的影响。

Sigi 在同构侧只提供了唯一的逻辑入口,即 SSREffectpayloadGetter 选项。在这个前提下,我们提供了 @sigi/ts-plugin 在编译时将这些逻辑删掉。这样即使是你在编写 SSR 业务时编写了大量 Node only 的逻辑,在编译 Client 端代码的时候,也会被轻松消除掉。

@Module('A')
export class ModuleA extends EffectModule<AState> {
  @SSREffect({
    skipFirstClientDispatch: true,
    payloadGetter: (req: Request) => {
      return require('md5')('hello')
    },
  })
  whatever(payload$: Observable<string>) {
    return payload$.pipe(
      map(() => this.createNoopAction())
    )
  }
}

      ↓ ↓ ↓ ↓ ↓ ↓
// TypeScript after transform:

import { EffectModule, Module } from '@sigi/core';
import { SSREffect } from '@sigi/ssr';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
interface AState {
}
@Module('A')
export class ModuleA extends EffectModule<AState> {
  @SSREffect({})
  whatever(payload$: Observable<string>) {
    return payload$.pipe(map(() => this.createNoopAction()));
  }
}

你可以下载 SSR example 项目并运行 yarn build:client 命令查看 Tree shaking 之后的效果。

依赖替换

Node 端与 Client 还有一个非常不一样的地方是: Client 端通常使用 http 请求获取数据,而在 Node 端我们可以使用更高效的 RPC方式甚至直接读取数据库、缓存等方式获取数据。

因为 Sigi 基于 DI 构建,所以我们可以很轻松的在 SSR 场景下将发请求/获取数据的 Service 替换成更高效的实现,并且完全不会侵入原有的业务逻辑。这里有一个简单的示例来看依赖替换 API 的形态:

Sigi 文档 · 依赖替换

import '@abraham/reflection'
import React from 'react'
import { render } from 'react-dom'
import { ClassProvider } from '@sigi/di'
import { useEffectModule, InjectionProvidersContext } from '@sigi/react'
import { HttpErrorClient } from './http-with-error.service'
import { HttpBetterClient } from './http-better.service'

import { AppModule } from './app.module'

const AppContainer = React.memo(({ appTitle }: { appTitle: string }) => {
  const [list, dispatcher] = useEffectModule(AppModule, {
    selector: (state) => state.list,
  })
  const loading = !list ? <div>loading</div> : null

  const title =
    list instanceof Error ? <h1>{list.message}</h1> : <h1>{appTitle}</h1>

  const listNodes = Array.isArray(list)
    ? list.map((value) => <li key={value}>{value}</li>)
    : null
  return (
    <div>
      {title}
      <button onClick={dispatcher.fetchList}>fetchList</button>
      <button onClick={dispatcher.cancel}>cancel</button>
      {loading}
      <ul>{listNodes}</ul>
    </div>
  )
})

function App() {
  const betterHttpProvider: ClassProvider<HttpErrorClient> = {
    provide: HttpErrorClient,
    useClass: HttpBetterClient,
  }
  return (
    <>
      <AppContainer appTitle="Always error" />
      <InjectionProvidersContext providers={[betterHttpProvider]}>
        <AppContainer appTitle="Better http client" />
      </InjectionProvidersContext>
    </>
  )
}

const rootElement = document.getElementById('app')
render(<App />, rootElement)

局限

只支持 React hooks 形式的 API

目前 Sigi 只支持 react hooks 形式的 API。

对于 React class component 我们也暂时不考虑提供相应的支持。

对于 Vue 2/3,我们已经有相应的计划,正在紧锣密鼓的进行中,顺利的话很快就能与大家见面。

只为 TypeScript 项目优化

我们对基于 Babel 的纯 JavaScript 项目与 Flow 项目的支持目前没有排期,但是将来会支持。其中主要的成本是需要抹平 BabelTypeScriptDecorator 实现上的差异,并且要考虑如何向纯 JavaScript 项目提供 TypeScript 中的 emitDecoratorMetadata 功能的 API。

体积

虽然 Sigi 源码已经尽量精简了,但是由于依赖了 RxJS 的大量特性,所以 Sigi 加上其依赖之后的体积 gzip 之后也达到了 16k 左右(immer ~ 6.29kb, rxjs ~ 6.8kb, sigi ~ 2.96kb)。但如果你在大型项目中使用,Sigi 高度的抽象和强大的功能一定能给你省下超过这个体积许多的业务代码体积

在未来我们会慢慢剥离一些 RxJS 的大体积依赖比如BehaviorSubjectReplaySubject,进一步优化体积。


Written by 太狼
Frontend Developer at day, Rustacean at night.